/*
 * BTClientNetworkManager.java
 *    Author: nfk
 *      Date: Sep 19, 2008
 *      
 *   Classes:
 *      BTClientNetworkManager
 */

package at.momberban.game.me;


import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.util.Vector;
import javax.bluetooth.BluetoothStateException;
import javax.bluetooth.DeviceClass;
import javax.bluetooth.DiscoveryAgent;
import javax.bluetooth.DiscoveryListener;
import javax.bluetooth.LocalDevice;
import javax.bluetooth.RemoteDevice;
import javax.bluetooth.ServiceRecord;
import javax.bluetooth.UUID;
import javax.microedition.io.Connector;
import javax.microedition.io.StreamConnection;
import javax.microedition.io.StreamConnectionNotifier;
import javax.microedition.rms.RecordEnumeration;
import javax.microedition.rms.RecordStore;
import javax.microedition.rms.RecordStoreException;


/**
 * A Bluetooth network manager client.
 * @author nfk
 */
public final class BTClientNetworkManager implements ClientNetworkManager, DiscoveryListener, Runnable {

    static {
        try {
            LocalDevice.getLocalDevice();
            supportsBluetooth = true;
        } catch (final BluetoothStateException ex) {
            supportsBluetooth = false;
        }

        if (supportsBluetooth()) {
            nm = new BTClientNetworkManager();
            uuid = new UUID[] { new UUID(BTServerNetworkManager.uuid, false) };

            /*
             * F'ugly workaround for wireless toolkit bug ID 6511054 (see Release Notes). "Calling the method
             * DiscoveryAgent.searchServices(...) can throw a NullPointerException if the Bluetooth device
             * doesn't offer any services."
             */
            checkAndStartWTKWorkaround();
        }
    }
    private static boolean                supportsBluetooth;
    private static BTClientNetworkManager nm;
    private static UUID[]                 uuid;
    private static final String           recordStoreName = "MomberBanClient";
    private static final char             cacheEntryDelim = '!';
    private final DiscoveryAgent          agent;

    private final Vector                  devices;
    private final Vector                  services;
    private final Vector                  serviceURLS;

    private String                        connectionURL;
    private StreamConnection              connection;
    private InputStream                   input;
    private OutputStream                  output;

    private EventGenerator                eventListener;
    private Thread                        receiver;

    private boolean                       devicesDiscovered;
    private boolean                       servicesDiscovered;

    private boolean                       connectionThreadFinished;


    /**
     * Returns the network manager instance.
     * @return the network manager instance
     */
    public static BTClientNetworkManager getManager() {
        return nm;
    }


    /**
     * @see at.momberban.game.me.ClientNetworkManager#connect(int)
     */
    public boolean connect(final int id) throws IOException {
        if (id < 0 || id >= serviceURLS.size()) {
            throw new IllegalArgumentException("Invalid ID: " + id);
        }

        if (isConnected()) {
            throw new IllegalStateException("Already connected.");
        }

        return connectByURL((String) serviceURLS.elementAt(id));
    }


    /** Helper method to connect by URL string. */
    private boolean connectByURL(final String url) throws IOException {
        this.connectionThreadFinished = false;
        this.connectionURL = url;

        new Thread(new Runnable() {

            /**
             * (non-Javadoc)
             * @see java.lang.Runnable#run()
             */
            public void run() {
                try {
                    connection = (StreamConnection) Connector.open(connectionURL, Connector.READ_WRITE, true);
                    input = connection.openInputStream();
                    output = connection.openOutputStream();
                } catch (final IOException ex) {
//                    throw new NullPointerException(ex.getMessage());
                    disconnect();
                }

                connectionThreadFinished = true;
                synchronized (agent) {
                    agent.notifyAll();
                }
            }

        }).start();

        synchronized (agent) {
            while (!this.connectionThreadFinished) {
                try {
                    this.agent.wait();
                } catch (final InterruptedException ex) {
                    // Ignore
                }
            }
        }

        return isConnected();
    }


    /**
     * @see at.momberban.game.me.ClientNetworkManager#reconnect()
     */
    public boolean reconnect() throws IOException {
        if (isConnected()) {
            throw new IllegalStateException("Already connected.");
        }

        if (!hasPreviousConnection()) {
            throw new UnsupportedOperationException("No previous connection.");
        }

        return connectByURL(this.connectionURL);
    }


    /**
     * @see at.momberban.game.me.ClientNetworkManager#discover()
     */
    public String[] discover() throws IOException {
        return discover(false);
    }


    /**
     * @see at.momberban.game.me.ClientNetworkManager#discover(boolean)
     */
    public String[] discover(final boolean nocache) throws IOException {
        String[] result = null;

        if (!nocache) {
            result = discoverCached();
        }

        if (result == null) {
            result = discoverNew();
        }

        return result;
    }


    /** Helper method to discover cached servers from persistence. */
    private String[] discoverCached() throws IOException {
        String[] result = null;

        try {
            final RecordStore rs = RecordStore.openRecordStore(recordStoreName, false);
            final RecordEnumeration re = rs.enumerateRecords(null, null, false);

            if (re.numRecords() > 0) {
                serviceURLS.removeAllElements();
                result = new String[re.numRecords()];

                String newItem = null;
                String[] tokens;
                byte[] b;
                int i = 0;
                while (re.hasNextElement()) {
                    b = re.nextRecord();
                    newItem = new String(b, 0, b.length);
                    tokens = StringUtilities.tokenize(newItem, cacheEntryDelim);

                    result[i] = tokens[0];
                    serviceURLS.addElement(tokens[1]);
                    i++;
                }
            }

            rs.closeRecordStore();
        } catch (final RecordStoreException ex) {
            result = null; // Ensure result is invalid if no record store is found etc.
        }

        return result;
    }


    /** Helper method to cache servers to persistence. */
    private void cacheServices(final String[] displayStrings) {
        try {
            RecordStore.deleteRecordStore(recordStoreName);
        } catch (final RecordStoreException ex) {
            // Ignore
        }

        RecordStore rs = null;
        try {
            rs = RecordStore.openRecordStore(recordStoreName, true);
            String nextItem = null;

            for (int i = 0; i < serviceURLS.size(); i++) {
                nextItem = displayStrings[i] + cacheEntryDelim + serviceURLS.elementAt(i);
                rs.addRecord(nextItem.getBytes(), 0, nextItem.length());
            }
        } catch (final RecordStoreException ex) {
            // Ignore
        } finally {
            if (rs != null) {
                try {
                    rs.closeRecordStore();
                } catch (final RecordStoreException ex) {
                    // Ignore
                }
            }
        }
    }


    /** Helper method to discover new servers. */
    private String[] discoverNew() throws IOException {
        try {
            discoverDevices();
            discoverServices();
        } catch (final BluetoothStateException ex) {
            throw new IOException(ex.getMessage());
        }

        final String[] result = retrieveStrings();

        if (result != null && result.length == serviceURLS.size()) {
            cacheServices(result);
        }

        return result;
    }


    /** Helper method to discover new devices. */
    private void discoverDevices() throws BluetoothStateException {
        this.devices.removeAllElements();
        this.agent.startInquiry(DiscoveryAgent.GIAC, this);

        synchronized (this.agent) {
            while (!this.devicesDiscovered) {
                try {
                    this.agent.wait();
                } catch (final InterruptedException ex) {
                    // Ignore
                }
            }
        }

        this.devicesDiscovered = false;
    }


    /** Helper method to discover new services. */
    private void discoverServices() throws BluetoothStateException {
        this.services.removeAllElements();
        this.serviceURLS.removeAllElements();

        RemoteDevice dev = null;
        for (int i = 0; i < devices.size(); i++) {
            dev = (RemoteDevice) devices.elementAt(i);
            this.agent.searchServices(null, uuid, dev, this);

            synchronized (this.agent) {
                while (!this.servicesDiscovered) {
                    try {
                        this.agent.wait();
                    } catch (final InterruptedException ex) {
                        // Ignore
                    }
                }
            }

            this.servicesDiscovered = false;
        }
    }


    /** Helper method to construct displayable entries from discovered servers. */
    private String[] retrieveStrings() throws IOException {
        String[] result = null;

        if (!services.isEmpty()) {
            result = new String[services.size()];

            for (int i = 0; i < result.length; i++) {
                final RemoteDevice dev = ((ServiceRecord) services.elementAt(i)).getHostDevice();
                result[i] = dev.getFriendlyName(false) + "/" + dev.getBluetoothAddress();
            }
        }

        return result;
    }


    /**
     * @see at.momberban.game.me.ClientNetworkManager#isConnected()
     */
    public boolean isConnected() {
        return (this.connection != null);
    }


    /**
     * @see at.momberban.game.me.ClientNetworkManager#hasPreviousConnection()
     */
    public boolean hasPreviousConnection() {
        return (this.connectionURL != null);
    }


    /**
     * @see at.momberban.game.me.NetworkManager#disconnect()
     */
    public void disconnect() {
        if (this.input != null) {
            try {
                this.input.close();
            } catch (final IOException ex) {
                // Ignore
            } finally {
                this.input = null;
            }
        }

        if (this.output != null) {
            try {
                this.output.close();
            } catch (final IOException ex) {
                // Ignore
            } finally {
                this.output = null;
            }
        }

        if (this.connection != null) {
            try {
                this.connection.close();
            } catch (final IOException ex) {
                // Ignore
            } finally {
                this.connection = null;
            }
        }
    }


    /**
     * @see at.momberban.game.me.NetworkManager#receive()
     */
    public String receive() throws IOException {
        if (!isConnected()) {
            throw new IllegalStateException("No connection.");
        }

        return StringUtilities.readLine(this.input);
    }


    /**
     * @see at.momberban.game.me.NetworkManager#send(java.lang.String)
     */
    public void send(final String s) throws IOException {
        if (s == null) {
            throw new NullPointerException("s must not be null.");
        }

        if (!isConnected()) {
            throw new IllegalStateException("No connection.");
        }

        this.output.write(s.getBytes());
        this.output.flush();
    }


    /** Forbid external construction. */
    private BTClientNetworkManager() {
        try {
            this.agent = LocalDevice.getLocalDevice().getDiscoveryAgent();
        } catch (final BluetoothStateException ex) {
            throw new IllegalStateException(ex.getMessage());
        }

        this.devices = new Vector();
        this.services = new Vector();
        this.serviceURLS = new Vector();
    }


    /**
     * @see javax.bluetooth.DiscoveryListener#deviceDiscovered(javax.bluetooth.RemoteDevice,
     *      javax.bluetooth.DeviceClass)
     */
    public void deviceDiscovered(final RemoteDevice arg0, final DeviceClass arg1) {
        this.devices.addElement(arg0);
    }


    /**
     * @see javax.bluetooth.DiscoveryListener#inquiryCompleted(int)
     */
    public void inquiryCompleted(final int arg0) {
        synchronized (this.agent) {
            this.agent.notifyAll();
        }

        this.devicesDiscovered = true;
    }


    /**
     * @see javax.bluetooth.DiscoveryListener#serviceSearchCompleted(int, int)
     */
    public void serviceSearchCompleted(final int arg0, final int arg1) {
        synchronized (this.agent) {
            this.agent.notifyAll();
        }

        this.servicesDiscovered = true;
    }


    /**
     * @see javax.bluetooth.DiscoveryListener#servicesDiscovered(int, javax.bluetooth.ServiceRecord[])
     */
    public void servicesDiscovered(final int arg0, final ServiceRecord[] arg1) {
        for (int i = 0; i < arg1.length; i++) {
            this.services.addElement(arg1[i]);
            this.serviceURLS.addElement(arg1[i].getConnectionURL(ServiceRecord.NOAUTHENTICATE_NOENCRYPT,
                    false));
        }
    }


    /**
     * @see java.lang.Runnable#run()
     */
    public void run() {
        while (isConnected()) {
            try {
                String s = receive();
                this.eventListener.write(s);
            } catch (final Throwable t) {
                disconnect();
                eventListener.write(Event.encode(Event.NETWORK_DISCONNECTED, null));
                break;
            }
        }

        this.receiver = null;
    }


    /**
     * @see at.momberban.game.me.NetworkManager#setEventListener(at.momberban.game.me.EventGenerator)
     */
    public void setEventListener(final EventGenerator listener) {
        this.eventListener = listener;
    }


    /**
     * @see at.momberban.game.me.ClientNetworkManager#startReceival()
     */
    public void startReceival() {
        if (this.receiver != null) {
            throw new IllegalStateException("Already receiving.");
        }

        if (!isConnected()) {
            throw new IllegalStateException("No connection.");
        }

        if (this.eventListener == null) {
            throw new IllegalStateException("No event listener set.");
        }

        this.receiver = new Thread(this);
        this.receiver.start();
    }


    /** Helper method to start dummy service for WTK bug (see static initializer block for more.) */
    private static void checkAndStartWTKWorkaround() {
        try {
            String friendlyName = LocalDevice.getLocalDevice().getFriendlyName();
            if (friendlyName.equals("WirelessToolkit")) {
                final String workaroundUUID = "e1c5633d1e8f49eab35fe44beaff042d";
                new Thread(new Runnable() {

                    /*
                     * @see java.lang.Runnable#run()
                     */
                    public void run() {
                        StreamConnectionNotifier listener = null;
                        try {
                            final String url = "btspp://localhost:" + workaroundUUID
                                    + ";name=WorkaroundListener";
                            listener = (StreamConnectionNotifier) Connector.open(url);
                        } catch (final IOException ex1) {
                            // Ignore
                        }

                        if (listener != null) {
                            StreamConnection connection = null;
                            while (true) {
                                try {
                                    connection = (StreamConnection) listener.acceptAndOpen();
                                    connection.close();
                                } catch (final IOException ex) {
                                    // Ignore
                                }
                            }
                        }
                    }

                }).start();
            }
        } catch (final BluetoothStateException ex) {
            // Ignore
        }
    }


    /**
     * Returns if the device supports bluetooth resp. if bluetooth is turned on.
     * @return
     */
    public static boolean supportsBluetooth() {
        return supportsBluetooth;
    }

}

/* End of BTClientNetworkManager.java */
